ShareViewController.swift (17635B)
1 // 2 // ShareViewController.swift 3 // share extension 4 // 5 // Created by Swift on 11/4/24. 6 // 7 8 import SwiftUI 9 import Social 10 import UniformTypeIdentifiers 11 12 let this_app: UIApplication = UIApplication() 13 14 class ShareViewController: SLComposeServiceViewController { 15 private var contentView: UIHostingController<ShareExtensionView>? 16 17 override func viewDidLoad() { 18 super.viewDidLoad() 19 self.view.tintColor = UIColor(DamusColors.purple) 20 21 DispatchQueue.main.async { 22 let contentView = UIHostingController(rootView: ShareExtensionView(extensionContext: self.extensionContext!, 23 dismissParent: { [weak self] in 24 self?.dismissSelf() 25 } 26 )) 27 self.addChild(contentView) 28 self.contentView = contentView 29 self.view.addSubview(contentView.view) 30 31 // set up constraints 32 contentView.view.translatesAutoresizingMaskIntoConstraints = false 33 contentView.view.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true 34 contentView.view.bottomAnchor.constraint (equalTo: self.view.bottomAnchor).isActive = true 35 contentView.view.leftAnchor.constraint(equalTo: self.view.leftAnchor).isActive = true 36 contentView.view.rightAnchor.constraint (equalTo: self.view.rightAnchor).isActive = true 37 } 38 } 39 40 func dismissSelf() { 41 super.didSelectCancel() 42 } 43 } 44 45 struct ShareExtensionView: View { 46 @State private var share_state: ShareState = .loading 47 let extensionContext: NSExtensionContext 48 @State private var state: DamusState? = nil 49 @State private var preUploadedMedia: [PreUploadedMedia] = [] 50 var dismissParent: (() -> Void)? 51 52 @Environment(\.scenePhase) var scenePhase 53 54 var body: some View { 55 VStack(spacing: 15) { 56 switch self.share_state { 57 case .loading: 58 ProgressView() 59 case .no_content: 60 Group { 61 Text("No content available to share", comment: "Title indicating that there was no available content to share") 62 .font(.largeTitle) 63 .multilineTextAlignment(.center) 64 .padding() 65 Text("There is no content available to share at this time. Please close this view and try again.", comment: "Label explaining that no content is available to share and instructing the user to close the view and try again.") 66 .multilineTextAlignment(.center) 67 .padding(.horizontal) 68 69 Button(action: { 70 self.done() 71 }, label: { 72 Text("Close", comment: "Button label giving the user the option to close the view when no content is available to share") 73 }) 74 .foregroundStyle(.secondary) 75 } 76 case .not_logged_in: 77 Group { 78 Text("Not Logged In", comment: "Title indicating that sharing cannot proceed because the user is not logged in.") 79 .font(.largeTitle) 80 .multilineTextAlignment(.center) 81 .padding() 82 83 Text("You cannot share content because you are not logged in. Please close this view, log in to your account, and try again.", comment: "Label explaining that sharing cannot proceed because the user is not logged in.") 84 .multilineTextAlignment(.center) 85 .padding(.horizontal) 86 87 Button(action: { 88 self.done() 89 }, label: { 90 Text("Close", comment: "Button label giving the user the option to close the sheet due to not being logged in.") 91 }) 92 .foregroundStyle(.secondary) 93 } 94 case .loaded(let content): 95 PostView( 96 action: .sharing(content), 97 damus_state: state! // state will have a value at this point 98 ) 99 case .cancelled: 100 Group { 101 Text("Cancelled", comment: "Title indicating that the user has cancelled.") 102 .font(.largeTitle) 103 .padding() 104 Button(action: { 105 self.done() 106 }, label: { 107 Text("Close", comment: "Button label giving the user the option to close the sheet from which they were trying to share.") 108 }) 109 .foregroundStyle(.secondary) 110 } 111 case .failed(let error): 112 Group { 113 Text("Error", comment: "Title indicating that an error has occurred.") 114 .font(.largeTitle) 115 .multilineTextAlignment(.center) 116 .padding() 117 Text("An unexpected error occurred. Please contact Damus support via [Nostr](damus:npub18m76awca3y37hkvuneavuw6pjj4525fw90necxmadrvjg0sdy6qsngq955) or [email](support@damus.io) with the error message below.", comment: "Label explaining there was an error, and suggesting next steps") 118 .multilineTextAlignment(.center) 119 Text("Error: \(error)") 120 Button(action: { 121 done() 122 }, label: { 123 Text("Close", comment: "Button label giving the user the option to close the sheet from which they were trying share.") 124 }) 125 .foregroundStyle(.secondary) 126 } 127 case .posted(event: let event): 128 Group { 129 Image(systemName: "checkmark.circle.fill") 130 .resizable() 131 .frame(width: 60, height: 60) 132 Text("Shared", comment: "Title indicating that the user has shared content successfully") 133 .font(.largeTitle) 134 .multilineTextAlignment(.center) 135 .padding(.bottom) 136 137 Link(destination: URL(string: "damus:\(event.id.bech32)")!, label: { 138 Text("Go to the app", comment: "Button label giving the user the option to go to the app after sharing content") 139 }) 140 .buttonStyle(GradientButtonStyle()) 141 142 Button(action: { 143 self.done() 144 }, label: { 145 Text("Close", comment: "Button label giving the user the option to close the sheet from which they shared content") 146 }) 147 .foregroundStyle(.secondary) 148 } 149 case .posting: 150 Group { 151 ProgressView() 152 .frame(width: 20, height: 20) 153 Text("Sharing", comment: "Title indicating that the content is being published to the network") 154 .font(.largeTitle) 155 .multilineTextAlignment(.center) 156 .padding(.bottom) 157 Text("Your content is being broadcasted to the network. Please wait.", comment: "Label explaining that their content sharing action is in progress") 158 .multilineTextAlignment(.center) 159 .padding() 160 } 161 } 162 } 163 .onAppear(perform: { 164 if setDamusState() { 165 self.loadSharedContent() 166 } 167 }) 168 .onDisappear { 169 Task { @MainActor in 170 self.state?.ndb.close() 171 } 172 } 173 .onReceive(handle_notify(.post)) { post_notification in 174 switch post_notification { 175 case .post(let post): 176 self.post(post) 177 case .cancel: 178 self.share_state = .cancelled 179 dismissParent?() 180 } 181 } 182 .onChange(of: scenePhase) { (phase: ScenePhase) in 183 guard let state else { return } 184 switch phase { 185 case .background: 186 print("txn: 📙 SHARE BACKGROUNDED") 187 Task { @MainActor in 188 state.ndb.close() 189 } 190 break 191 case .inactive: 192 print("txn: 📙 SHARE INACTIVE") 193 break 194 case .active: 195 print("txn: 📙 SHARE ACTIVE") 196 state.pool.ping() 197 @unknown default: 198 break 199 } 200 } 201 .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in 202 guard let state else { return } 203 print("SHARE ACTIVE NOTIFY") 204 if state.ndb.reopen() { 205 print("SHARE NOSTRDB REOPENED") 206 } else { 207 print(" SHARE NOSTRDB FAILED TO REOPEN closed: \(state.ndb.is_closed)") 208 } 209 } 210 .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { obj in 211 guard let state else { return } 212 print("txn: 📙 SHARE BACKGROUNDED") 213 Task { @MainActor in 214 state.ndb.close() 215 } 216 } 217 } 218 219 func post(_ post: NostrPost) { 220 self.share_state = .posting 221 guard let state else { 222 self.share_state = .failed(error: "Damus state not initialized") 223 return 224 } 225 guard let full_keypair = state.keypair.to_full() else { 226 self.share_state = .not_logged_in 227 return 228 } 229 guard let posted_event = post.to_event(keypair: full_keypair) else { 230 self.share_state = .failed(error: "Cannot convert post data into a nostr event") 231 return 232 } 233 state.postbox.send(posted_event, on_flush: .once({ flushed_event in 234 if flushed_event.event.id == posted_event.id { 235 DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: { // Offset labor perception bias 236 self.share_state = .posted(event: flushed_event.event) 237 }) 238 } 239 else { 240 self.share_state = .failed(error: "Flushed event is not the event we just tried to post.") 241 } 242 })) 243 } 244 245 @discardableResult 246 private func setDamusState() -> Bool { 247 guard let keypair = get_saved_keypair(), 248 keypair.privkey != nil else { 249 self.share_state = .not_logged_in 250 return false 251 } 252 state = DamusState(keypair: keypair) 253 return true 254 } 255 256 func loadSharedContent() { 257 guard let extensionItem = extensionContext.inputItems.first as? NSExtensionItem else { 258 share_state = .failed(error: "Unable to get item provider") 259 return 260 } 261 262 var title = "" 263 264 // Check for the attributed text from the extension item 265 if let attributedContentData = extensionItem.userInfo?[NSExtensionItemAttributedContentTextKey] as? Data { 266 if let attributedText = try? NSAttributedString(data: attributedContentData, options: [.documentType: NSAttributedString.DocumentType.rtf], documentAttributes: nil) { 267 let plainText = attributedText.string 268 print("Extracted Text: \(plainText)") 269 title = plainText 270 } else { 271 print("Failed to decode RTF content.") 272 } 273 } else { 274 print("Content is not in RTF format or data is unavailable.") 275 } 276 277 // Iterate through all attachments to handle multiple images 278 for itemProvider in extensionItem.attachments ?? [] { 279 if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { 280 itemProvider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { (item, error) in 281 if let url = item as? URL { 282 283 attemptAcquireResourceAndChooseMedia( 284 url: url, 285 fallback: processImage, 286 unprocessedEnum: {.unprocessed_image($0)}, 287 processedEnum: {.processed_image($0)}) 288 289 } else if let image = item as? UIImage { 290 // process it directly if shared item is uiimage (example: image shared from Facebook, Signal apps) 291 chooseMedia(PreUploadedMedia.uiimage(image)) 292 } else { 293 self.share_state = .failed(error: "Failed to load image content") 294 } 295 } 296 } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { 297 itemProvider.loadItem(forTypeIdentifier: UTType.movie.identifier) { (item, error) in 298 if let url = item as? URL { 299 attemptAcquireResourceAndChooseMedia( 300 url: url, 301 fallback: processVideo, 302 unprocessedEnum: {.unprocessed_video($0)}, 303 processedEnum: {.processed_video($0)} 304 ) 305 306 } else { 307 self.share_state = .failed(error: "Failed to load video content") 308 } 309 } 310 } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { 311 itemProvider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { (item, error) in 312 // Sharing URLs from iPhone/Safari to Damus also follows this pathway 313 // Sharing Photos or Links from macOS/Finder or macOS/Safari to Damus sets item-provider conforming to UTType.url.identifier and therefore takes this pathway 314 315 if let url = item as? URL { 316 // Sharing Photos from macOS/Finder 317 if url.absoluteString.hasPrefix("file:///") { 318 attemptAcquireResourceAndChooseMedia( 319 url: url, 320 fallback: processImage, 321 unprocessedEnum: {.unprocessed_image($0)}, 322 processedEnum: {.processed_image($0)}) 323 324 } else { 325 // Sharing URLs from iPhone/Safari to Damus 326 self.share_state = .loaded(ShareContent(title: title, content: .link(url))) 327 } 328 } else if let data = item as? Data, 329 let string = String(data: data, encoding: .utf8), 330 let url = URL(string: string) { 331 // Sharing Links from macOS/Safari, does not provide title 332 self.share_state = .loaded(ShareContent(title: "", content: .link(url))) 333 } else { 334 self.share_state = .failed(error: "Failed to load text content") 335 } 336 } 337 } else { 338 share_state = .no_content 339 } 340 } 341 342 func attemptAcquireResourceAndChooseMedia(url: URL, fallback: (URL) -> URL?, unprocessedEnum: (URL) -> PreUploadedMedia, processedEnum: (URL) -> PreUploadedMedia) { 343 if url.startAccessingSecurityScopedResource() { 344 // Have permission from system to use url out of scope 345 print("Acquired permission to security scoped resource") 346 chooseMedia(unprocessedEnum(url)) 347 } else { 348 // Need to copy URL to non-security scoped location 349 guard let newUrl = fallback(url) else { return } 350 chooseMedia(processedEnum(newUrl)) 351 } 352 } 353 354 func chooseMedia(_ media: PreUploadedMedia) { 355 self.preUploadedMedia.append(media) 356 if extensionItem.attachments?.count == preUploadedMedia.count { 357 self.share_state = .loaded(ShareContent(title: "", content: .media(preUploadedMedia))) 358 } 359 } 360 } 361 362 private func done() { 363 extensionContext.completeRequest(returningItems: [], completionHandler: nil) 364 } 365 366 private enum ShareState { 367 case loading 368 case no_content 369 case not_logged_in 370 case loaded(ShareContent) 371 case failed(error: String) 372 case cancelled 373 case posting 374 case posted(event: NostrEvent) 375 } 376 } 377